Skip to main content

01 - 为什么需要持久化执行

前置:不需要。

本篇回答:为什么「挂了重跑」在生产上不可行、检查点该在什么时机写、幂等键该怎么设计、以及什么场景不需要这套机制。

本篇会用到的词

意思
检查点把执行到某一步的状态存到进程外,崩溃后据此恢复
副作用代码对外部世界造成的、重跑无法抵消的改变。本篇那个 30 步任务里有三步是这种
幂等键随请求一起发给下游、让它去重的那个确定性字符串。崩溃窗口消不掉,只能靠它让重跑变得无害
序列化把内存里的对象转成能存进数据库的字节。数据库连接、HTTP client、闭包这些都序列化不了 —— 这是自己手搓检查点时的第二个坑
全量快照 / 增量每步存整份状态,还是只存变化的部分。前者写入量随步数平方增长,后者恢复时要回放
挂起任务停下来等外部事件(等用户回复、等审批),可能持续几天。好的实现挂起时不占用任何进程

一、一个具体的翻车现场

一个市场调研 Agent,任务是"分析五家竞品,写一份报告",执行过程约 30 步:

「分析五家竞品,写一份报告」—— 约 30 步,其中三步不可撤销步骤 1-5搜索公开资料5 次搜索 API步骤 6-20抓网页并总结15 次模型调用步骤 21发飞书通知不可撤销步骤 22-28交叉对比生成图表7 次模型调用步骤 29写入数据库不可撤销步骤 30发送报告邮件不可撤销在第 25 步崩溃,「从头重跑」的代价不只是那 20 次模型调用的钱和二十来分钟 ——飞书通知会再发一遍。用户看到的是同一份报告被通知了两次,而这一步没有任何办法撤回。深色的三步是整个任务里唯一真正棘手的部分:前面那些步骤重跑只是浪费,这三步重跑是事故。
判断一个任务需不需要持久化执行,先数一数它有几个这样的深色格子。一个都没有的话,挂了重跑通常就够了。

红色的三步有不可撤销的外部副作用,这是后面所有讨论的前提。

跑到第 18 步时,K8s 滚动更新把 Pod 干掉了。

1.1 从头重跑的三个后果

后果性质说明
前 17 步的模型费用重复支付成本任务花 3 美元,现在要花 6 美元
用户已等 12 分钟,要再等 20 分钟体验长任务尤其致命
第 21 步的通知会发第二遍正确性若在第 25 步再挂,群里收到两条进度通知;若崩在第 30 步之后,用户收到两封报告邮件

前两个是钱和体验,第三个是正确性问题 —— 系统对外产生了不该产生的行为。

1.2 自己存进度会撞上什么

第一反应通常是这样:

# 从存储里取出上次的进度,没有就从头开始
state = load_state(task_id) or {"step": 0, "results": []}

# 从上次中断的位置继续,而不是从 0 开始
for i in range(state["step"], 30):
result = run_step(i) # ← 真正干活:调模型、调工具、写数据
state["results"].append(result) # 累积中间结果
state["step"] = i + 1 # 把"下一步该从哪儿开始"记下来
save_state(task_id, state) # 落盘。注意:这一行在 run_step 之后

思路是对的 —— 这就是检查点。但真写下去会依次撞上六个问题:

「执行完一步就把状态存下来」—— 思路是对的,但会依次撞上六个问题执行完了,落盘之前崩溃这一步会重跑副作用重复状态里有httpx.Client序列化不了两个进程同时恢复同一个任务全部重复执行要等用户回复挂起三天进程不能一直占着某步结果 2 MB每步全量存数据库被撑爆恢复时代码已经改了存的状态对不上这六个问题就是那四条成熟路线各自在解决的东西 —— 它们不是「框架的功能列表」,是这张图上每一格的答案。其中 ① 最容易被忽略、后果最严重:不管检查点做得多细,「执行完成」和「落盘」之间必然有一个窗口,下一节专门讲它。
看到这六格再回头看那些框架的文档,读法会完全不同:每一个看起来很抽象的机制名词,都对应这里的某一格。

这六个问题就是持久化执行框架真正在解决的东西。检查点只是入场券。下面三节分别处理它们。

二、检查点:把状态放到进程外

检查点(checkpoint):把"执行到哪儿了 + 当前状态是什么"写到进程之外的存储,使得原进程死亡后,另一个进程能据此继续执行。

关键词是进程外。存在内存或进程局部变量里的不叫检查点。

2.1 什么能进检查点,什么不能

这是上一节的问题 ②。判据是能否被序列化并在另一个进程里重建

类型能否存处理方式
基本类型、dict、list直接存
业务数据对象(dataclass / Pydantic)存字段,恢复时重建
HTTP 连接、数据库连接池不存,恢复时重新建立
文件句柄、socket不存,改存文件路径 / 地址
闭包、lambda、生成器不存,改存能重建它的参数
大文本、图片、抓下来的网页⚠️存到对象存储,检查点里只放引用

实践规则:检查点里只放数据,不放能力。 连接、客户端、句柄这些"能力",恢复时按配置重建即可,存下来既存不了也没意义。

2.2 写入时机:先干活还是先记账

这是上一节的问题 ①,也是整个机制里最容易被忽略、后果最严重的一处。

不管检查点做得多细,"执行完成"和"检查点落盘"之间必然存在一个时间窗口。崩溃落在窗口里,这一步就会重跑:

写后 —— 先干活,再记账写前 —— 先记账,再干活执行 run_step已产生副作用崩溃窗口状态还没落盘写检查点恢复后该步会重跑 —— 副作用执行两次写检查点标记「开始执行」崩溃窗口活还没干执行 run_step恢复后该步可能没跑 —— 副作用执行零次两边都不安全,而且没有第三种排法 —— 只要「执行」和「落盘」是两个动作,它们之间就一定有窗口。唯一的出路是让重跑变得无害:给每一步一个确定性的幂等键,下游服务凭这个键去重。这件事没有任何框架能替你做。
这就是为什么所有持久化执行方案最后都会告诉你「你的步骤必须幂等」—— 不是它们偷懒,是这个窗口在物理上消不掉。

两种顺序都不能保证"恰好一次" —— 一个可能多做,一个可能漏做。这不是实现不够好,而是分布式系统的固有性质:没有事务保护的情况下,本地状态变更与外部副作用无法原子地同时完成。

生产上的做法是两段式,把窗口缩到可判定:

# 第一段:先记录"我要开始执行第 i 步了",带上唯一的执行标识
# 崩溃恢复时看到 started 但没有 completed,就知道这一步状态不明
save_step_marker(task_id, i, status="started", attempt_id=uuid4())

# 第二段:执行。幂等键让下游能识别出这是同一次逻辑操作的重试
result = run_step(i, idempotency_key=f"{task_id}:{i}")

# 第三段:记录结果。到这里这一步才算真正完成
save_step_marker(task_id, i, status="completed", result=result)

恢复时的判定逻辑:

检查点里的状态含义恢复动作
无记录没开始过正常执行
startedcompleted状态不明,可能已产生副作用重试,依赖幂等键去重
completed已完成跳过,直接取 result

中间那一行是所有设计的重点。 你无法知道那一步到底做没做,只能重试 —— 所以下一节的幂等不是可选项。

2.3 频率:恢复精度与写放大的取舍

检查点写得越勤,恢复时损失越少,但写入压力越大:

粒度最多损失30 步任务的写入次数
每步一次一步30
每 5 步一次五步6
只在关键节点到上个关键点2–3

DBOS 官方文档给出的开销是每步一次写,加每个工作流两次额外的写(开头记输入、结尾记结果):

one database write per step (to checkpoint the step's outcome) plus two additional database writes per workflow

换算:30 步任务 = 32 次数据库写。若平台每天跑 10 万个这样的任务,就是每天 320 万次写入。

判断依据不是"步骤多少",而是"单步重跑的代价"

  • 单步是一次 8K token 的模型调用(几分钱、几秒)→ 值得每步都存
  • 单步是一次本地字符串处理(微秒级、零成本)→ 存它纯属浪费

2.4 全量还是增量

这是问题 ⑤。每次都存全量状态,会随对话历史线性增长:

30 步任务的累计写入量 —— 全量快照是平方增长9 MB0步骤 1步骤 30全量快照累计写入约 9 MB增量 + 周期性快照累计写入约 1 MB每一步都存整份状态,而状态本身随对话历史线性变长,两者相乘就是平方增长。增量方案只在快照点存全量,其余步只存变化的部分。增量的代价是��恢复变慢:要先读最近一次快照,再把之后的增量逐条回放上去。写得少了,读就得多做事。
什么时候需要换成增量:状态里带着完整对话历史、步数上到几十、并且这类任务是常态。三个条件都不满足的话,全量快照的实现简单得多,别提前优化。

增量方案必须配一个强制快照点,否则恢复时要回溯的增量链会无限增长。触发条件通常是两个同时用:

  • 写入次数达到阈值(写得多,早点做快照)
  • 经过的步数达到上限(写得少的字段也要兜底,否则它的"最近快照"可能在几百步之前)

第二个条件容易被漏掉。这个模式和数据库的 WAL + checkpoint、Redis 的 AOF + RDB 完全同构。

2.5 检查点会长大:清理与保留期

一个跑了三个月的 Agent 平台,检查点表里堆着所有用户的完整对话、上传的文档内容、工具返回的内部数据。

这张表通常没人管,直到两件事之一发生:存储成本变得难看,或者合规来问"用户数据保留多久"。

需要在设计期就定下三件事:

说明
保留期任务完成后检查点留多久。做完就删?留 7 天供排查?
清理触发定时任务扫描过期,还是任务结束时同步删除
敏感级别检查点里有对话内容和工具返回值,敏感级别不低于用户表,访问控制和加密不能省

保留期不只是省存储 —— 它同时是数据保留合规的执行点

三、幂等:承认会重跑

幂等(idempotency):同一个操作执行一次和执行多次,对外部世界产生的效果相同。

3.1 为什么绕不开

由 2.2 节的结论直接推出:存在无法消除的崩溃窗口 → 某些步骤必然会重跑 → 唯一的出路是让重跑不产生新的副作用

思路要从"想办法让它绝不重跑"转成"承认它会重跑,让重跑无害"。

3.2 幂等键怎么设计

# 不幂等:每次调用都在群里产生一条新消息
send_feishu_message(chat_id, text)

# 幂等:带去重键。服务端认这个键,同键的第二次请求不再产生新消息
# 键必须满足两个条件:
# 1) 确定性 —— 重跑时能算出完全相同的值,所以不能用 uuid4() 或时间戳
# 2) 唯一性 —— 不同的逻辑操作不能撞键,所以要带上任务和步骤维度
send_feishu_message(chat_id, text, dedup_key=f"{task_id}:step21")

键的取值要覆盖"什么算同一次逻辑操作":

场景幂等键说明
任务内某个固定步骤{task_id}:{step}最常见
循环内的第 N 次{task_id}:{step}:{index}循环变量要进键
按业务实体去重{task_id}:notify:{user_id}同一用户只通知一次
允许人工重试整个任务键里不要attempt_id否则重试会被当成新操作

最容易犯的错是用 uuid4() 或时间戳做键 —— 重跑时算出来的值不同,去重完全失效,而且不会报错,只会静默地发两遍。

3.3 下游不支持去重键怎么办

不是所有接口都提供 dedup_key。这时把去重挪到自己这一侧:

# 用一次原子的"占位"来决定谁有权执行。
# SET NX 只在键不存在时成功,多个并发进程里只有一个能拿到 True。
# ex=86400 是兜底过期,避免占位键永久残留。
acquired = redis.set(f"sideeffect:{task_id}:step21", "1", nx=True, ex=86400)
if acquired:
send_feishu_message(chat_id, text) # 只有拿到占位的那个进程会真正发送
# 没拿到说明别的执行流已经发过(或正在发),直接跳过

这个方案有个必须知道的缺陷:占位成功之后、send 完成之前崩溃,占位键已经在了,重试时会被跳过 —— 消息永远发不出去。它把"可能发两遍"换成了"可能一遍都不发"。

选哪个取决于业务:通知类可以接受偶尔漏发,付款类绝对不能重复扣款。没有免费的正确性,只有选择在哪一侧犯错。

3.4 关于 exactly-once

分布式系统中不存在真正的 exactly-once 投递。业界所说的 exactly-once 语义,实现上都是:

at-least-once 投递  +  幂等消费  =  effectively-once

任何框架宣称提供 exactly-once,指的都是这个组合。框架能保证的是"至少执行一次","恰好生效一次"取决于你的幂等键和下游服务是否认它。

四、确定性重放

第三个机制,也是 03 篇04 篇的基础。

4.1 两种恢复模型

快照式 · LangGraph重放式 · Temporal / DBOS / Restate存什么:当前各字段的值channel_values怎么恢复:把值灌回去,从中断处继续无确定性约束存不了连接、闭包存什么:发生过哪些外部操作以及它们各自的返回值怎么恢复:代码从第一行重跑,已记录的操作直接返回历史值连接、闭包重跑即得代码必须是确定性的「代码必须确定性」具体意味着:不能直接用 random、时间戳、UUID,也不能直接调模型 —— 重�跑时它们会给出不同的结果,历史就对不上了。这些操作必须被包进框架提供的那层壳子里(Activity、step、run()),让框架把第一次的返回值记下来,重跑时原样返回。
这一条差异决定了迁移成本:从没有持久化改成快照式,通常只要加配置;改成重放式,要把代码里所有「不确定的操作」都揪出来包一层。

4.2 哪些操作破坏确定性

重放式要求代码重跑时走出完全相同的路径。以下操作会让路径分叉:

操作为什么破坏正确做法
random()两次取值不同,可能进不同分支放进被记录的步骤,或用 SDK 的确定性随机
time.now()同上用 SDK 提供的工作流时钟
直接发 HTTP / 查库重放时会真的再打一次外部系统放进被记录的步骤
直接调用大模型同样的 prompt 两次输出可能不同放进被记录的步骤
起线程、依赖系统调度顺序执行顺序不可复现用 SDK 的并发原语

Temporal 官方文档点名了 LLM 调用:

Workflow code must be deterministic to support replay. To handle non-deterministic operations like API calls, LLM/AI invocations, database queries, and other external interactions, put them in Activities.

4.3 代码演进带来的非确定性

这是问题 ⑥,也是重放式在生产上真正会咬人的地方:部署了新版本,而线上还有任务跑在旧版本的执行历史上。

新代码重放旧历史,操作序列对不上,任务直接卡死。

对 Agent 场景影响尤其大,因为 prompt 和编排逻辑恰恰是改动最频繁的部分。缓解办法:

  • 把 prompt、工具调用、模型调用全部放进被记录的步骤(本来也必须如此)
  • 工作流骨架只保留最稳定的流程控制,改得越少越安全
  • 长任务的发布节奏要考虑在跑实例的生命周期

五、和网关重试的边界

Agent 网关 · 路由与容错也在处理失败,两者尺度不同:

两者都在处理失败,但尺度差了三到六个数量级管什么时间尺度状态放在哪手段一次请求失败毫秒到秒内存里,请求结束就消失重试 · 换后端 · 熔断网关容错一个任务中断分钟到天进程外的存储里,崩溃后仍在检查点 · 重放 · 恢复持久化执行两者是接力关系:网关把单次调用的失败率从 1% 压到 0.01%,剩下那 0.01%,加上进程崩溃和部署更新,由持久化执行兜住。谁也替代不了谁。
把它们混为一谈会得到两种典型错误:指望网关重试救回一个跑了二十分钟的任务,或者给一次几百毫秒的模型调用套上工作流引擎。

网关保证"这次调用能成功",持久化执行保证"这个任务不会丢"。 两者叠加,不可互相替代。

六、什么时候不需要

情况判断
请求-响应式,几秒内返回让用户重发一次,成本远低于引入框架
无外部副作用(纯查询、纯生成)重跑只浪费钱,不产生正确性问题
单次重跑成本低于 1 分钱直接重跑
团队不理解确定性约束重放式方案会写出大量隐蔽 bug,且只在崩溃恢复时才暴露

量化判据任务平均时长 × 崩溃概率 × 单次重跑成本 得到的月度期望损失,若低于引入框架的运维与学习成本,就先不要上。

一个例外:只要任务里存在不可撤销的外部副作用(付款、发邮件、下单、发消息),哪怕任务只跑 3 秒,幂等键也是必须的。这时不一定需要完整的持久化执行框架,但第三节的内容跳不过去。

下一篇02 - LangGraph Checkpointer:框架自带的方案存的到底是什么,恢复点怎么算出来。

← 回到 专题索引  ·  Agent Infra 板块总览